#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HTML2MD - HTMLtoMarkdown转换器

这是一个功能强大的HTML到Markdown转换工具，特别针对中文文本进行了优化。
程序具备完善的HTML清理、安全防护和智能标点转换功能。

作者: Pinocchio
版本: 1.0.0
许可证: MIT
"""

import argparse
import configparser
import logging
import os
import re
import sys
from html.parser import HTMLParser

try:
    import chardet
    CHARDET_AVAILABLE = True
except ImportError:
    CHARDET_AVAILABLE = False
    print("警告: chardet不可用，编码检测将受限")

try:
    from bs4 import BeautifulSoup
    BS4_AVAILABLE = True
except ImportError:
    BS4_AVAILABLE = False
    print("错误: BeautifulSoup4是必需的但未安装")
    sys.exit(1)

try:
    from html2text import HTML2Text
    HTML2TEXT_AVAILABLE = True
except ImportError:
    HTML2TEXT_AVAILABLE = False
    print("错误: html2text是必需的但未安装")
    sys.exit(1)

def parse_attributes_config(config_str):
    """
    将allowed_attributes配置项解析为字典格式
    
    Args:
        config_str (str): 配置字符串，格式为 "tag:attr1,attr2;tag2:attr1,attr2"
        
    Returns:
        dict: 解析后的字典，键为标签名，值为属性列表
    """
    result = {}
    # 按分号分割不同的标签配置
    for item in config_str.split(';'):
        if ':' not in item:
            continue
        # 分割标签名和属性列表
        tag, attrs = item.strip().split(':', 1)
        # 分割属性列表并去除空格
        attrs_list = [a.strip() for a in attrs.split(',')]
        result[tag] = attrs_list
    return result

def generate_default_config(config_path):
    """
    生成带中文注释的默认配置文件
    
    Args:
        config_path (str): 配置文件保存路径
    """
    config = configparser.ConfigParser()
    
    # 设置默认值
    config['preprocessing'] = {
        'strict_mode': 'true',
        'allowed_tags': 'p,h1,h2,h3,h4,h5,h6,div,span,strong,em,b,i,u,s,strike,del,ins,blockquote,hr,ul,ol,li,dl,dt,dd,table,tr,td,th,caption,thead,tbody,tfoot,img,a,br,pre,code,figure,figcaption,aside,details,summary',
        'allowed_attributes': 'img:src,alt,width,height; a:href,title,target; table:border,cellspacing,cellpadding; td:colspan,rowspan; th:colspan,rowspan; pre:class; code:class; div:class; span:class; blockquote:cite; video:src,width,height,controls; audio:src,controls; iframe:src,width,height',
        'remove_empty_tags': 'true',
        'merge_br': 'true'
    }
    
    config['punctuation'] = {
        'smart_punctuation': 'true',
        'max_periods': '1',
        'quotes_type': '""',
        'protect_patterns': r'https?://[^\s]+|[\w.-]+@[\w.-]+|\d+\.\d+|[A-Z]\.[A-Z]\.|[A-Z]\.'
    }
    
    config['postprocessing'] = {
        'remove_empty_lines': 'true',
        'strip_trailing_spaces': 'true',
        'link_style': 'relative',
        'image_base_url': '',
        'multi_dot_fix': 'true'
    }
    
    config['logging'] = {
        'log_level': 'INFO',
        'log_file': 'html2md.log'
    }
    
    with open(config_path, 'w', encoding='utf-8') as f:
        # 写入中文注释
        f.write("# HTML2MD 配置文件\n")
        f.write("# 此文件控制 HTML 到 Markdown 转换的所有行为\n\n")
        
        f.write("# ==================== HTML 预处理配置 ====================\n")
        f.write("# strict_mode: 是否启用严格模式，只允许白名单中的标签\n")
        f.write("# allowed_tags: 允许的 HTML 标签列表\n")
        f.write("# allowed_attributes: 允许的 HTML 属性列表，格式为 tag:attr1,attr2\n")
        f.write("# remove_empty_tags: 是否移除空标签\n")
        f.write("# merge_br: 是否合并连续的 <br> 标签\n")
        f.write("[preprocessing]\n")
        f.write("strict_mode = true\n")
        f.write("allowed_tags = p,h1,h2,h3,h4,h5,h6,div,span,strong,em,b,i,u,s,strike,del,ins,blockquote,hr,ul,ol,li,dl,dt,dd,table,tr,td,th,caption,thead,tbody,tfoot,img,a,br,pre,code,figure,figcaption,aside,details,summary\n")
        f.write("allowed_attributes = img:src,alt,width,height; a:href,title,target; table:border,cellspacing,cellpadding; td:colspan,rowspan; th:colspan,rowspan; pre:class; code:class; div:class; span:class; blockquote:cite; video:src,width,height,controls; audio:src,controls; iframe:src,width,height\n")
        f.write("remove_empty_tags = true\n")
        f.write("merge_br = true\n\n")
        
        f.write("# ==================== 标点符号处理配置 ====================\n")
        f.write("# smart_punctuation: 是否启用智能标点转换（英文标点转中文标点）\n")
        f.write("# max_periods: 最大连续句号数\n")
        f.write("# quotes_type: 中文引号样式\n")
        f.write("# protect_patterns: 需要保护的模式（如 URL、邮箱等不转换标点）\n")
        f.write("[punctuation]\n")
        f.write("smart_punctuation = true\n")
        f.write("max_periods = 1\n")
        f.write("quotes_type = \"\"\n")
        f.write("protect_patterns = https?://[^\\s]+|[\\w.-]+@[\\w.-]+|\\d+\\.\\d+|[A-Z]\\.[A-Z]\\.|[A-Z]\\.\n\n")
        
        f.write("# ==================== 后处理配置 ====================\n")
        f.write("# remove_empty_lines: 是否移除多余空行\n")
        f.write("# strip_trailing_spaces: 是否清除行尾空格\n")
        f.write("# link_style: 链接处理样式（relative/absolute）\n")
        f.write("# image_base_url: 图片基础 URL\n")
        f.write("# multi_dot_fix: 是否将多个句号转换为省略号\n")
        f.write("[postprocessing]\n")
        f.write("remove_empty_lines = true\n")
        f.write("strip_trailing_spaces = true\n")
        f.write("link_style = relative\n")
        f.write("image_base_url = \n")
        f.write("multi_dot_fix = true\n\n")
        
        f.write("# ==================== 日志配置 ====================\n")
        f.write("# log_level: 日志级别（DEBUG/INFO/WARNING/ERROR）\n")
        f.write("# log_file: 日志文件路径\n")
        f.write("[logging]\n")
        f.write("log_level = INFO\n")
        f.write("log_file = html2md.log\n")

def load_config(config_path=None):
    """
    从文件加载配置，包含默认值回退机制
    
    Args:
        config_path (str, optional): 配置文件路径
        
    Returns:
        dict: 处理后的配置字典
    """
    config = configparser.ConfigParser()
    
    # 设置默认配置
    default_config = {
        'preprocessing': {
            'strict_mode': 'true',
            'allowed_tags': 'p,h1,h2,h3,h4,h5,h6,div,span,strong,em,b,i,u,s,strike,del,ins,blockquote,hr,ul,ol,li,dl,dt,dd,table,tr,td,th,caption,thead,tbody,tfoot,img,a,br,pre,code,figure,figcaption,aside,details,summary',
            'allowed_attributes': 'img:src,alt,width,height; a:href,title,target; table:border,cellspacing,cellpadding; td:colspan,rowspan; th:colspan,rowspan; pre:class; code:class; div:class; span:class; blockquote:cite; video:src,width,height,controls; audio:src,controls; iframe:src,width,height',
            'remove_empty_tags': 'true',
            'merge_br': 'true'
        },
        'punctuation': {
            'smart_punctuation': 'true',
            'max_periods': '1',
            'quotes_type': '""',
            'protect_patterns': r'https?://[^\s]+|[\w.-]+@[\w.-]+|\d+\.\d+|[A-Z]\.[A-Z]\.|[A-Z]\.'
        },
        'postprocessing': {
            'remove_empty_lines': 'true',
            'strip_trailing_spaces': 'true',
            'link_style': 'relative',
            'image_base_url': '',
            'multi_dot_fix': 'true'
        },
        'logging': {
            'log_level': 'INFO',
            'log_file': 'html2md.log'
        }
    }
    
    # 应用默认配置
    for section, options in default_config.items():
        if not config.has_section(section):
            config.add_section(section)
        for key, value in options.items():
            if not config.has_option(section, key):
                config.set(section, key, value)
    
    # 读取用户配置
    if config_path and os.path.exists(config_path):
        config.read(config_path, encoding='utf-8')
    
    # 类型转换与验证
    processed = {}
    
    # preprocessing
    processed['strict_mode'] = config.getboolean('preprocessing', 'strict_mode')
    processed['allowed_tags'] = [t.strip() for t in config['preprocessing']['allowed_tags'].split(',')]
    processed['allowed_attributes'] = parse_attributes_config(config['preprocessing']['allowed_attributes'])
    processed['remove_empty_tags'] = config.getboolean('preprocessing', 'remove_empty_tags')
    processed['merge_br'] = config.getboolean('preprocessing', 'merge_br')
    
    # punctuation
    processed['smart_punctuation'] = config.getboolean('punctuation', 'smart_punctuation')
    processed['max_periods'] = config.getint('punctuation', 'max_periods')
    processed['quotes_type'] = config['punctuation']['quotes_type']
    processed['protect_patterns'] = config['punctuation']['protect_patterns'].split('|')
    
    # postprocessing
    processed['remove_empty_lines'] = config.getboolean('postprocessing', 'remove_empty_lines')
    processed['strip_trailing_spaces'] = config.getboolean('postprocessing', 'strip_trailing_spaces')
    processed['link_style'] = config['postprocessing']['link_style']
    processed['image_base_url'] = config['postprocessing']['image_base_url']
    processed['multi_dot_fix'] = config.getboolean('postprocessing', 'multi_dot_fix')
    
    # logging
    processed['log_level'] = config['logging']['log_level']
    processed['log_file'] = config['logging']['log_file']
    
    return processed

def clean_html(html_content, config):
    """
    HTML预处理主函数，移除冗余内容并规范化结构
    
    Args:
        html_content (str): 原始HTML内容
        config (dict): 配置字典
        
    Returns:
        str: 清理后的HTML内容
    """
    # 配置参数
    strict_mode = config['strict_mode']
    allowed_tags = config['allowed_tags']
    allowed_attrs = config['allowed_attributes']
    remove_empty_tags = config['remove_empty_tags']
    merge_br = config['merge_br']
    
    # 应该保留的结构标签
    structural_tags = ['html', 'body', 'div', 'span', 'section', 'article', 'main', 'header', 'footer', 'nav']
    
    # 解析HTML
    soup = BeautifulSoup(html_content, 'html.parser')
    
    # 第一阶段：移除非内容区域
    non_content_tags = ['script', 'style', 'meta', 'noscript', 'link', 'head', 'title',
                        'svg', 'canvas', 'iframe', 'frameset', 'frame', 'object', 'embed']
    for tag in soup.find_all(non_content_tags):
        tag.decompose()
    
    # 第二阶段：清理标签属性
    for tag in soup.find_all(True):
        # 移除不在白名单中的标签（严格模式下）
        # 即使在严格模式下也允许结构标签
        if strict_mode and tag.name not in allowed_tags and tag.name not in structural_tags:
            tag.decompose()
            continue
            
        # 处理属性
        for attr in list(tag.attrs.keys()):
            # 检查是否在允许属性列表中
            if tag.name in allowed_attrs and attr in allowed_attrs[tag.name]:
                continue
                
            # 特殊处理data-*属性
            if attr.startswith('data-'):
                if 'data-*' in allowed_attrs.get(tag.name, []):
                    continue
                else:
                    del tag[attr]
            # 特殊处理事件属性（XSS防护）
            elif attr.startswith('on'):
                del tag[attr]
            else:
                del tag[attr]
        
        # 特殊标签处理
        if tag.name == 'img':
            # 检查src是否存在且有效
            if 'src' not in tag.attrs or not tag['src'].strip():
                tag.decompose()
            else:
                # 保留宽高属性（仅当为数字时）
                for prop in ['width', 'height']:
                    if prop in tag.attrs:
                        try:
                            int(tag[prop])
                        except (ValueError, TypeError):
                            del tag[prop]
                        
        elif tag.name == 'a':
            # 检查href是否为有效URL
            if 'href' not in tag.attrs or not re.match(r'^[a-z]+://', tag['href']):
                # 对于相对链接，根据配置可能需要保留
                if 'href' in tag.attrs and tag['href'].strip():
                    # 保留相对链接但移除onclick和其它事件处理器
                    pass
                else:
                    tag.decompose()
            else:
                # 保留title属性（如果存在且非空）
                if 'title' in tag.attrs and not tag['title'].strip():
                    del tag['title']
                    
        elif tag.name in ['table', 'tr', 'td', 'th']:
            # 移除无效的colspan/rowspan
            for prop in ['colspan', 'rowspan']:
                if prop in tag.attrs:
                    try:
                        int(tag[prop])
                    except (ValueError, TypeError):
                        del tag[prop]
    
    # 第三阶段：结构优化
    if remove_empty_tags:
        # 移除空标签
        for tag in soup.find_all(True):
            if not tag.text.strip() and not tag.find_all():
                tag.decompose()
    
    if merge_br:
        # 合并连续的<br>标签
        for br in soup.find_all('br'):
            if br.previous_sibling and br.previous_sibling.name == 'br':
                br.decompose()
    
    # 返回规范化后的HTML
    return str(soup)

def setup_html2text(config):
    """
    配置html2text设置
    
    Args:
        config (dict): 配置字典
        
    Returns:
        HTML2Text: 配置好的HTML2Text实例
    """
    h = HTML2Text()
    
    # 基础配置
    h.body_width = 0  # 禁用自动换行
    h.unicode_snob = True
    h.escape_snob = True
    h.escape_underscores = True
    
    # 链接处理策略
    if config['link_style'] == 'relative':
        h.baseurl = config['image_base_url']  # 自动补全相对路径
    elif config['link_style'] == 'absolute':
        h.baseurl = ''  # 保留完整URL
    
    # 图片处理策略
    if config['image_base_url']:
        h.images_to_alt = True  # 仅保留alt文本
        h.images_as_html = False
    
    # 表格处理
    h.pad_tables = True  # 表格对齐
    h.wrap_list_items = True
    
    return h

def convert_to_md(cleaned_html, config):
    """
    执行HTML到Markdown的核心转换
    
    Args:
        cleaned_html (str): 清理后的HTML内容
        config (dict): 配置字典
        
    Returns:
        str: 转换后的Markdown内容
    """
    h = setup_html2text(config)
    
    # 执行转换
    md_content = h.handle(cleaned_html)
    
    # 修复常见转换问题
    # 1. 处理多余空行
    if config['remove_empty_lines']:
        md_content = re.sub(r'\n{3,}', '\n\n', md_content)
    
    # 2. 修复标题格式
    md_content = re.sub(r'^(#{1,6})\s+', r'\1 ', md_content, flags=re.MULTILINE)
    
    # 3. 处理代码块
    md_content = re.sub(r'```(\w+)?\n', r'```\1\n', md_content)
    
    return md_content

def normalize_punctuation(text, config):
    """
    智能标点转换主函数
    
    Args:
        text (str): 待处理的文本
        config (dict): 配置字典
        
    Returns:
        str: 标点转换后的文本
    """
    if not config['smart_punctuation']:
        return text
    
    # 1. 识别保护区域
    protected_ranges = []
    for pattern in config['protect_patterns']:
        try:
            for match in re.finditer(pattern, text):
                protected_ranges.append((match.start(), match.end()))
        except re.error:
            # 跳过无效正则表达式模式
            continue
    
    # 2. 合并重叠区域
    protected_ranges.sort(key=lambda x: x[0])
    merged = []
    for start, end in protected_ranges:
        if merged and merged[-1][1] >= start:
            merged[-1] = (merged[-1][0], max(merged[-1][1], end))
        else:
            merged.append((start, end))
    
    # 3. 转换非保护区域标点
    new_text = []
    last_end = 0
    for start, end in merged:
        # 转换保护区间前的文本
        if last_end < start:
            segment = text[last_end:start]
            # 英文标点转中文标点
            segment = segment.replace('.', '。').replace(',', '，').replace('!', '！').replace('?', '？')
            segment = segment.replace(':', '：').replace(';', '；').replace('(', '（').replace(')', '）')
            segment = segment.replace('[', '【').replace(']', '】').replace('{', '｛').replace('}', '｝')
            segment = segment.replace('\"', config['quotes_type'][0] if len(config['quotes_type']) >= 1 else '\"')
            segment = segment.replace("'", '‘' if len(config['quotes_type']) >= 2 else "\'")
            new_text.append(segment)
        new_text.append(text[start:end])
        last_end = end
    
    # 转换剩余部分
    if last_end < len(text):
        segment = text[last_end:]
        segment = segment.replace('.', '。').replace(',', '，').replace('!', '！').replace('?', '？')
        segment = segment.replace(':', '：').replace(';', '；').replace('(', '（').replace(')', '）')
        segment = segment.replace('[', '【').replace(']', '】').replace('{', '｛').replace('}', '｝')
        segment = segment.replace('\"', config['quotes_type'][0] if len(config['quotes_type']) >= 1 else '\"')
        segment = segment.replace("'", '‘' if len(config['quotes_type']) >= 2 else "\'")
        new_text.append(segment)
    
    result = ''.join(new_text)
    
    # 4. 多句号修复
    if config['multi_dot_fix']:
        result = re.sub(r'\.{2,}', '…', result)
    
    return result

def apply_post_processing(md_content, config):
    """
    执行完整的后处理流程
    
    Args:
        md_content (str): Markdown内容
        config (dict): 配置字典
        
    Returns:
        str: 后处理后的Markdown内容
    """
    # 早期返回如果内容为空
    if not md_content.strip():
        return md_content
    
    # 1. 标点规范化
    md_content = normalize_punctuation(md_content, config)
    
    # 2. 移除多余空行
    if config['remove_empty_lines']:
        lines = md_content.split('\n')
        new_lines = []
        prev_empty = False
        for line in lines:
            if line.strip() == '':
                if not prev_empty:
                    new_lines.append(line)
                    prev_empty = True
            else:
                new_lines.append(line)
                prev_empty = False
        md_content = '\n'.join(new_lines)
    
    # 3. 清理行尾空格
    if config['strip_trailing_spaces']:
        md_content = '\n'.join([line.rstrip() for line in md_content.split('\n')])
    
    # 4. 特殊字符处理
    md_content = md_content.replace('  ', ' ')  # 双空格转单空格
    
    return md_content

def read_file(file_path, encoding='auto'):
    """
    安全读取文件，支持自动编码检测
    
    Args:
        file_path (str): 文件路径
        encoding (str): 文件编码，默认为'auto'自动检测
        
    Returns:
        str: 文件内容
    """
    if encoding == 'auto' and CHARDET_AVAILABLE:
        with open(file_path, 'rb') as f:
            raw_data = f.read()
        result = chardet.detect(raw_data)
        encoding = result['encoding'] or 'utf-8'
    elif encoding == 'auto':
        encoding = 'utf-8'
    
    try:
        with open(file_path, 'r', encoding=encoding) as f:
            return f.read()
    except UnicodeDecodeError:
        # 尝试GB18030（兼容GBK）
        try:
            with open(file_path, 'r', encoding='gb18030') as f:
                return f.read()
        except:
            # 最后手段：替换错误字符
            with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
                return f.read()

def write_file(file_path, content):
    """
    安全写入文件，创建父目录
    
    Args:
        file_path (str): 文件路径
        content (str): 文件内容
    """
    os.makedirs(os.path.dirname(file_path) if os.path.dirname(file_path) else '.', exist_ok=True)
    with open(file_path, 'w', encoding='utf-8') as f:
        f.write(content)

def init_logging(config):
    """
    初始化日志系统
    
    Args:
        config (dict): 配置字典，包含日志级别和日志文件路径
    """
    log_level = getattr(logging, config['log_level'], logging.INFO)
    log_file = config['log_file']
    
    # 创建日志格式
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )
    
    # 文件处理器
    file_handler = logging.FileHandler(log_file, encoding='utf-8')
    file_handler.setFormatter(formatter)
    file_handler.setLevel(log_level)
    
    # 控制台处理器
    console_handler = logging.StreamHandler()
    console_handler.setFormatter(formatter)
    console_handler.setLevel(log_level)
    
    # 根日志记录器配置
    root_logger = logging.getLogger()
    root_logger.setLevel(log_level)
    # 清除任何现有的处理器
    root_logger.handlers = []
    root_logger.addHandler(file_handler)
    root_logger.addHandler(console_handler)
    
    logging.info("日志系统初始化完成")

def parse_arguments():
    """
    解析命令行参数
    
    Returns:
        argparse.Namespace: 解析后的命令行参数
    """
    parser = argparse.ArgumentParser(
        description='HTML to Markdown converter with Chinese optimization',
        epilog='Example: python html2md.py -i input.html -o output.md -c config.cfg'
    )
    
    # 配置相关参数
    parser.add_argument('--create-config',
                        help='生成默认配置文件到指定路径')
    
    # 必需参数（除非生成配置文件）
    parser.add_argument('-i', '--input',
                        help='输入HTML文件路径（支持glob模式）')
    parser.add_argument('-o', '--output',
                        help='输出Markdown文件路径')
    
    # 配置相关参数
    parser.add_argument('-c', '--config',
                        help='配置文件路径')
    
    # 日志控制参数
    parser.add_argument('--log-level', default='INFO',
                        choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'],
                        help='日志级别')
    parser.add_argument('--log-file', default='html2md.log',
                        help='日志文件路径')
    
    # 编码处理参数
    parser.add_argument('--encoding', default='auto',
                        help='输入文件编码（auto/utf-8/gbk/latin1）')
    
    return parser.parse_args()

def main():
    """
    主函数，协调所有模块工作
    """
    args = parse_arguments()
    
    # 1. 生成配置文件模式
    if args.create_config:
        generate_default_config(args.create_config)
        print(f"✓ 默认配置已生成到 {args.create_config}")
        return
    
    # 2. 检查必需参数
    if not args.input or not args.output:
        print("错误: 除非使用 --create-config，否则 -i/--input 和 -o/--output 都是必需的")
        return
    
    # 3. 加载配置
    config = load_config(args.config)
    # 仅在明确指定时才用命令行参数覆盖配置
    if args.log_level != 'INFO':  # INFO是默认值，只有在明确设置时才覆盖
        config['log_level'] = args.log_level
    if args.log_file != 'html2md.log':  # html2md.log是默认值
        config['log_file'] = args.log_file
    
    # 4. 初始化日志
    init_logging(config)
    
    # 5. 处理输入文件
    import glob
    input_paths = glob.glob(args.input) if '*' in args.input or '?' in args.input else [args.input]
    
    if not input_paths:
        logging.error(f"没有文件匹配模式: {args.input}")
        return
    
    for input_path in input_paths:
        try:
            # 读取文件（自动编码检测）
            content = read_file(input_path, args.encoding)
            
            # 预处理
            cleaned_html = clean_html(content, config)
            
            # 核心转换
            md_content = convert_to_md(cleaned_html, config)
            
            # 后处理
            final_content = apply_post_processing(md_content, config)
            
            # 写入文件
            write_file(args.output, final_content)
            logging.info(f"✓ 转换完成: {input_path} → {args.output}")
            
        except Exception as e:
            logging.error(f"✗ 处理失败: {input_path} - {str(e)}")
            continue

if __name__ == '__main__':
    main()
